垃圾收集器和内存分配策略

程序计数器、 虚拟机栈、 本地方法栈3个区域随线程而生, 随线程而灭。这几个区域的内存分配和回收都具备确定性, 在这几个区域内就不需要过多考虑回收的问题, 因为方法结束或者线程结束时, 内存自然就跟随着回收了。Java堆不一样,一个接口中的多个实现类需要的内存可能不一样, 一个方法中的多个分支需要的内存也可能不一样, 我们只有在程序处于运行期间时才能知道会创建哪些对象, 这部分内存的分配和回收都是动态的, 垃圾收集器所关注的是这部分内存。

对象的生命周期

引用计数器算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加1,引用失效时,计数器就减1,任何时刻计数器为0的对象就是不可能再被使用的。

这种方法实现简单,判断效率高,但是主流虚拟机没有采用这种方法,因为它很难解决对象之间相互循环引用的问题。

可达性分析方法

这个算法的基本思路就是通过一系列的称为“GC Roots” 的对象作为起始点, 从这些节点开始向下搜索, 搜索所走过的路径称为引用链 , 当一个对象到GC Roots没有任何引用链相连(用图论的话来说, 就是从GC Roots到这个对象不可达) 时, 则证明此对象是不可用的。

在Java中,可以作为GC roots 的对象包括以下几种。

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 本地方法栈中JNI引用的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

再谈引用

在JDK 1.2以前, Java中的引用的定义很传统: 如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址, 就称这块内存代表着一个引用。 这种定义很纯粹, 但是太过狭隘, 一个对象在这种定义下只有被引用或者没有被引用两种状态, 对于如何描述一些“食之无味, 弃之可惜” 的对象就显得无能为力。

在JDK 1.2之后, Java对引用的概念进行了扩充, 将引用分为强引用 、 软引用 、 弱引用 、 虚引用4种, 这4种引用强度依次逐渐减弱。

强引用就是程序代码中普遍存在的 new生成的对象的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

软引用是用来描述一些还有用但是非必需的对象,对于软引用关联的对象,在系统要发生内存溢出异常之前,将会把这些对象列进回收范围内进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。

弱引用也用来描述非必需对象,但是强度比软引用更弱,被弱引用关联的对象只能生存到下一次垃圾收集发生之前,垃圾收集器工作时,无论当前内存是否足够,都会回收只被弱引用关联的对象。

虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

生存还是死亡

即使在可达性分析算法中不可达的对象, 也并非是“非死不可” 的, 要真正宣告一个对象死亡, 至少要经历两次标记过程: 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链, 那它将会被第一次标记并且进行一次筛选, 筛选的条件是此对象是否有必要执行finalize()方法。 当对象没有覆盖finalize()方法, 或者finalize()方法已经被虚拟机调用过, 虚拟机将这两种情况都视为“没有必要执行” 。就直接进行GC.

回收方法区

方法区也就是HotSpot虚拟机中的永生代也有一定的垃圾收集,主要回收两部分内容,废弃常量和不再使用的类型。

回收废弃常量与回收 Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池 中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用 常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且 垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接 口)、方法、字段的符号引用也与此类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就 比较苛刻了。需要同时满足下面三个条件:

·该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。

·加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP的重加载等,否则通常是很难达成的。

·该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方 法。

垃圾收集算法

标记清除算法

最基础的收集算法就是标记清除算法,分为标记和清除两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。主要不足有两个,一是效率,标记和清除两个过程的效率都不高,另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,会导致以后有较大对象时没有内存分配而又一次触发GC.

标记-复制算法

此方法将内存划分为相等的两块,每次使用其中的一块,当这一块用完了,就将还存活的对象复制到另一块上,然后把已使用过的内存空间一次性清理掉。这样使得每次都是对整个半区进行内存回收,分配时就不用考虑内存碎片的问题,但是代价是内存缩小为了原来的一半。

现在的商用虚拟机都采用这种收集算法来回收新生代。但是进行了一定的改进,不是按照1比1进行划分。将内存划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用一块eden和其中一块Survivor。当回收时,将Eden 和Survivor中还存活的对象一次性复制到另外一块Survivor 中。然后清理掉Eden和Survivor .HotSpot 虚拟机设置的默认比例是8:1:1.当Survivor 空间不够用时,需要依赖其他内存(老年代)进行分配担保。

标记整理算法

复制收集算法在对象存活率较高时就要进行较多的复制,所以一般老年代不选用这种算法

根据老年代特点,有另外一种标记-整理算法,标记过程和前面标记清除过程一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

根据对象的存活周期的不同将Java堆分为新生代和老年代,根据各个代的特点选择不同的收集算法。新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,所以就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集,而老年代中因为对象存活率高,没有额外的空间对他进行分配担保,就必选使用标记—清理 或者 标记 —整理算法来回收。

HotSpot 的算法实现

Hotspot虚拟机在实现上面这些算法时,必须对算法的执行效率有严格的考量,才能保证虚拟机高效运行。

枚举根节点

当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机有办法直接得知哪些地方存放着对象引用,在Hotspot虚拟机中,使用一组称为OopMap的数据结构来实现这个目的,在类加载完成的时候,Hotspot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,在GC扫描时,就直接知道这些信息了。

安全点

Hotspot并没有为每条指令都生成oopmap,只有特定的位置记录了这些信息,这些位置称为安全点。即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。

安全区域

安全区域是指在一段代码片段中,引用关系不会发生变化,在这个区域的任何地方开始GC都是安全的,可以把safe region 看做是被扩展了的safepoint.

垃圾收集器

垃圾收集器是内存回收的具体实现。下面讨论的1收集器基于JDK1.7后的HotSpot虚拟机。

  • Serial 收集器

最基本,发展历史最悠久的收集器。单线程收集器。它在进行垃圾收集时,必须暂停所有其他所有的工作线程。直到它收集结束。

Serial 是运行在Client 模式下的默认新生代收集器,优点是简单高效。

  • ParNew 收集器

Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集外,其余和Serial几乎完全一样。是运行在Server模式下的虚拟机中首选的新生代收集器。同时,也只有Serial 和ParNew 能和CMS收集器配合工作。

  • Parallel Scavenge 收集器

使用复制算法的新生代收集器。也是并行的多线程收集器。它的设计目标是达到一个可控制的吞吐量。所以也被成为吞吐量优先的收集器。

  • Serial Old 收集器

Serial 收集器的老年代版本,是一个单线程使用标记-整理算法的收集器。主要给Client模式下的虚拟机使用。

  • Parallel Old

parallel Scaenge 收集器的老年代版本,使用多线程和标记-整理算法。

  • CMS收集器(current mark sweep)

一种以获取最短回收停顿时间为目标的收集器。基于标记清除算法实现。主要有四个步骤,初始标记,并发标记,重新标记,并发清除。其中,初始标记、 重新标记这两个步骤仍然需要“Stop The World” 。 初始标记仅仅只是标记一下GC Roots能直接关联到的对象, 速度很快, 并发标记阶段就是进行GC RootsTracing的过程, 而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录, 这个阶段的停顿时间一般会比初始标记阶段稍长一些, 但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作, 所以, 从总体上来说, CMS收集器的内存回收过程是与用户线程一起并发执行的。 下图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的时间。

CMS也有很明显的缺点,主要是1对CPU资源非常敏感,2无法处理浮动垃圾,3,使用标记清除算法会产生大量碎片。

  • G1 收集器

一个面向服务器应用的垃圾收集器,主要有以下优点,并行与并发,分代收集,空间整合(整体上标记整理,局部是复制算法),可预测的停顿(相比CMS的一大优势)。

将堆化为多个大小相等的独立区域(region),虽然保留了新生代,老年代的概念,但是新生代老年代不再物理隔离了.它们都是一部分region的集合

G1 之所以能建立可预测的停顿时间模型,是因为它可以有计划的避免在整个Java堆中进行全区域的垃圾收集,G1追踪各个region里面的垃圾堆积的价值大小,后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的region.

主要划分为四个步骤

初始标记- 并发标记- 最终标记 - 筛选回收

初始标记仅标记一下GC root 能关联到的对象,这个阶段需要停顿,但耗时短

并发标记是从GC root 进行可达性分析 ,找出存活对象. 耗时长,但是可并发

最终标记是为了修正并发标记期间因用户程序继续运作导致标记变动的记录 ,需要停顿,但是可多条线程同时执行

筛选回收 根据各个region的回收价值和成本进行排序.根据用户期望的GC停顿时间来执行回收计划.这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

内存分配与回收策略

对象的内存分配,其实就是往堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能直接分配在老年代中。分配细节取决于当前使用的是哪一种垃圾收集器组合

对象优先在Eden分区分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次MInor GC.

1
2
Minor GC 也称为新生代GC,指发生在新生代的垃圾收集动作。因为Java对象大多具有朝生夕灭的特点,所以说新生代GC 非常频繁,一般回收速度也很快。
Full GC(Major GC)也称为老年代GC,发生在老年代的GC,比Minor GC 慢10倍以上。基本上也会伴随新生代GC

大对象直接进入老年代

大对象指的是需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组,大对象对内存分配来说就是一个坏消息,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾回收以获取足够的连续空间来安置它们。

长期存活的对象进入老年代

为了在回收时识别哪些对象应放在新生代,哪些应放在老年代,虚拟机给每个对象定义了一个对象年龄计数器,如果对象在Eden出生并经过第一次Minor GC 后仍然存活,并能被Survroir 容纳的话,将被移动到Survrior 空间中,并且对象年龄设置为1,对象在Survrior 中每熬过一次Minor GC 年龄就增加一岁,到达一定程度(默认15岁)就会晋升到老年代中。

动态对象年龄判定

为了能更好的适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代, 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半, 年龄大于或等于该年龄的对象就可以直接进入老年代, 无须等到MaxTenuringThreshold中要求的年龄。

空间分配担保

在发生Minor GC之前, 虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间, 如果这个条件成立, 那么Minor GC可以确保是安全的。 如果不成立, 则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。 如果允许, 那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于, 将尝试着进行一次Minor GC, 尽管这次Minor GC是有风险的; 如果小于, 或者HandlePromotionFailure设置不允许冒险, 那这时也要改为进行一次Full GC。